package com.project.search;

import com.project.search.model.DocInfo;
import com.project.search.model.Result;
import com.project.search.model.Weight;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;

// 通过这个类, 来完成整个的搜索过程
public class DocSearcher {

    // 停用词文件的路径
    private static final String STOP_WORD_PATH = "/root/save_index_structure/stopwords.txt";

    // 使用 HashSet 来保存这些停用词
    private HashSet<String> stopWords = new HashSet<>();

    // 这里需要加上索引对象的实例(因为查询要去正排和倒排索引结构中查询)
    private Index index = new Index();

    // 并且要同时完成索引加载的过程(即 .load() 方法)
    // 就可以在这个类的构造方法中调用.
    public DocSearcher() {
        index.load();
        loadStopWords();
    }

    // 该方法来, 完成整个搜索过程
    // 参数(输入部分): 用户所给出的查询词.
    // 返回值(输出部分): 搜索结果的集合(就是在搜索引擎上面搜索完毕后出来的那些结果)
    public List<Result> search(String query) {
        // 1. [分词] 针对 query 这个查询词进行分词.
        List<Term> oldTerms = ToAnalysis.parse(query).getTerms();
        List<Term> terms = new ArrayList<>();

        // 针对分词结果, 使用停用词表进行过滤
        for (Term term : oldTerms) {
            if (stopWords.contains(term.getName())) {
                // 如果分词结果中包含停用词, 则不作处理, 直接跳过
                continue;
            }
            // 非停用词才是真正的分词
            terms.add(term); // terms 就不包含停用词了
        }

        // 2. [触发] 针对分词结果来查询倒排索引.
        List<Weight> ppp = new ArrayList<>();
        List<List<Weight>> termResult = new ArrayList<>();
        for (Term term : terms) {
            // 获取分词后的结果
            String word = term.getName();
            // 从倒排索引中查询, 并获取文档列表
            List<Weight> invertedList = index.getInverted(word);
            if (invertedList == null) {
                // 如果 invertedList 为空, 说明这个词在所有文档中都不存在.
                // 那就不管这个词, 直接掠过
                continue;
            }
            // 如果存在, 那么就需要对每一个 由分词获取的文档列表进行汇总
            // 注意这里是使用 addAll() 方法, 添加一组 invertedList 数据, 而不是一个
            termResult.add(invertedList);
            ppp.addAll(invertedList);

        }
        // 2.1 [合并] 针对多个分词结果触发的相同文档, 进行权重合并
        List<Weight> allTermResult = mergeResult(termResult);

        // 3. [排序] 针对触发返回的结果按照权重, 降序排序.
        // 因为 权重属性是在 Weight 类中的, 因此需要给排序操作传一个比较器.
        // new Comparator<Weight>() 有好几步操作:
        //    1) 创建实现了 Comparator 接口的类;
        //    2) 重写了里面的 compare() 方法;
        //    3) 然后又 new 出这个类的实例
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                // 如果是升序排序: return o1.getWeight() - o2.getWeight();
                // 如果是降序排序: return o2.getWeight() - o1.getWeight();
                // 当然, 不用去死记, 调试一下试一试就可以了.
                return o2.getWeight() - o1.getWeight();
            }
        });
        // 4. [包装结果] 针对排序结果去查询正排索引, 构造出要返回的数据.
        // 取出排序好的 Weight链 中的每个元素(即文档列表), 这就需要一个 List 去装载这些元素
        List<Result> results = new ArrayList<>();
        for (Weight weight : allTermResult) {
            DocInfo docInfo = index.getDocInfo(weight.getDocId());

            // 将每个文档又进一步封装
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            // 这一步获取 "描述" 操作, 还需要从正文中获取, 不能把正文所有内容都拿出来
            // 所以这里使用一个方法去获取描述
            result.setDesc(genDesc(docInfo.getContent(), terms));
            results.add(result);
        }
        return results;
    }

    // 通过这个内部类, 来描述每一篇 "文章" 的位置
    static class Pos {
        // row 表示从 每个分词结果中 得到的文章列表
        public int row;

        // col 表示 文章列表 里面的 每一篇具体的 "文章(Weight)"
        public int col;

        public Pos(int row, int col) {
            this.row = row;
            this.col = col;
        }
    }

    private List<Weight> mergeResult(List<List<Weight>> termResult) {
        // 行row 表示文章列表, 列col 表示文章

        // 1. 针对每一行进行排序(按照id进行升序排序, 方便后续根据id来进行合并)
        for (List<Weight> articleList : termResult) {
            articleList.sort(new Comparator<Weight>() {
                @Override
                public int compare(Weight o1, Weight o2) {
                    // 升序排序
                    return o1.getDocId() - o2.getDocId();
                }
            });
        }

        // 2. 借助 堆结构(优先级队列) 进行合并
        // target 表示合并的结果
        List<Weight> target = new ArrayList<>();

        // 创建一个优先级队列, 并指定比较规则
        PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
            @Override
            public int compare(Pos o1, Pos o2) {
                // 先根据 pos值 找到对应的 Weight对象(具体文章), 再根据 Weight 的 docId 设置比较规则(小根堆)
                Weight w1 = termResult.get(o1.row).get(o1.col);
                Weight w2 = termResult.get(o2.row).get(o2.col);
                return w1.getDocId() - w2.getDocId();
            }
        });

        // 初始化队列(往队列中添加元素), 把每一行的第一个元素(文章) 放到队列中
        for (int row = 0; row < termResult.size(); row++) {
            // 最开始的元素就是每个数组的0下标位置
            queue.offer(new Pos(row, 0));
        }

        // 循环的取 队列首元素
        while (!queue.isEmpty()) {
            // 取出队首元素(队列中的元素是Pos, Pos是描述文章的位置)
            Pos minPos = queue.poll();
            // 取出文章
            Weight curWeight = termResult.get(minPos.row).get(minPos.col);

            // 观察取到的文章, 是否与 target 中的文章相同
            // 如果相同, 则说明这个文章在多个分词中都出现过, 权重要更高些, 因此要合并权重(合并权重的同时就已经去重了)
            if (!target.isEmpty()) {
                // 如果target中已经存在文章, 取出上次插入的文章(即末尾文章)
                Weight lastWeight = target.get(target.size() - 1);
                if (lastWeight.getDocId() == curWeight.getDocId()) {
                    // 如果两篇文章相同, 则合并权重, 这里采用两篇相同文章权重相加的规则
                    lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
                }else {
                    // 如果两篇文章不相同, 则把当前文章添加到 target 里面
                    target.add(curWeight);
                }
            }else {
                // 如果 target 里面没有任何一篇文章, 则把当前文章添加进去
                target.add(curWeight);
            }

            // 当前元素处理之后, 要把这个元素对应的数组下标向后移动
            Pos nextArticle = new Pos(minPos.row, minPos.col + 1);
            // 判断数组是否取完了
            if (nextArticle.col >= termResult.get(nextArticle.row).size()) {
                // 如果已经把一个文章列表给取完了, 直接 continue
                continue;
            }
            try {
                queue.offer(nextArticle);

            }catch (IndexOutOfBoundsException e) {
                e.printStackTrace();
            }
        }
        return target;
    }

    private String genDesc(String content, List<Term> terms) {
        // 先遍历查询词的分词. 看看哪一个分词是在 content 中存在的.
        // 设置在正文中找到分词结果的位置, 初始设为 -1
        int firstPos = -1;
        for(Term term : terms) {
            // 但是这里要注意, 第三方分词库对分词进行转小写了
            // 所以必须将正文也转成小写, 然后再去查询
            String word = term.getName();
            // 此处需要的是 "原词匹配", 让 word 是独立出来的词才能查找出来, 而不是作为词的一部分
            // 使用 replaceAll 把独立查询词的边界都换成空格.
            content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");
            // 再去查询 查询词两边带空格的情况, 这样就能在正文查出独立的查询词了(包含边界)
            firstPos = content.indexOf(" " + word + " ");
            if (firstPos >= 0) {
                // 说明找到该词在正文的位置
                // 然后直接跳出循环, 执行后面的摘取描述逻辑
                break;
            }
        }
        if (firstPos == -1) {
            // 如果该变量为 -1, 说明这个分词在正文中不存在, 只是在标题中存在
            // 这里就可以直接取正文的前 120 个字符作为描述.
            if (content.length() > 120) {
                return content.substring(0, 120) + "...";
            }
            // 如果正文不足120个字符, 正文就作为描述全部返回
            return content;
        }

        // 从 firstPos 作为基准位置, 往前找 35 个字符, 作为描述的起始位置
        String desc = "";

        // 如果基准位置前面没有35个字符, 那么就从 0 位置开始
        int descBegin = firstPos < 35 ? 0 : firstPos - 35;

        if (descBegin + 120 > content.length()) {
            // 如果在 descBegin 位置后面没有 120 个字符.
            // 就从 descBegin 开始往后截取所有内容作为描述.
            desc = content.substring(descBegin);
        }else {
            desc = content.substring(descBegin, descBegin + 120) + "...";
        }

        // 在此处加上一个替换操作, 把描述中包含查询词结果的部分, 加上一层 <i> 标签
        for (Term term : terms) {
            String word = term.getName();
            // 注意, 这里需要进行 "原词匹配", 也就是当查询词为 List 的时候, 不能把 ArrayList 中的 List 给单独标红.
            // (?i) 是 正则表达式, 表示不区分大小写去匹配
            desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
        }
        return desc;
    }

    // 加载停用词到内存中
    public void loadStopWords() {
        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))) {
            while (true) {
                // 注意停用词表每一个停用词都会换行, readLine() 就是读取每一行, 但是不会读取到换行符
                String line = bufferedReader.readLine();
                if (line == null) {
                    // 说明读取完毕
                    break;
                }

                // 把每一个停用词加载到 HashSet(从磁盘加载到内存)
                stopWords.add(line);
            }

        }catch (IOException e) {
            e.printStackTrace();
        }

    }

    // 验证搜索功能
    public static void main(String[] args) {
        DocSearcher searcher = new DocSearcher();
        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.print("-> ");
            String query = scanner.nextLine();
            List<Result> results = searcher.search(query);
            for (Result result : results) {
                System.out.println("===================");
                System.out.println(result);
            }
        }
    }
}
